﻿using JESAI.Schedule.Cron.Consts;
using JESAI.Schedule.Cron.Exceptions;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace JESAI.Schedule.Cron.CronExpressions
{
    public sealed partial class CronExpression
    {
        public static CronExpression Parse(string expression) => Parse(expression, null);

        public static CronExpression Parse(string expression, CronFormat? format = CronFormat.Stanard) => TryParse(expression, format, static v => v, e => throw e());

        public static CronExpression? TryParse(string expression) => TryParse(expression, null);

        public static CronExpression? TryParse(string expression, CronFormat? format = CronFormat.Stanard) => TryParse(expression ?? string.Empty, format, static v => (CronExpression?)v, _ => null);

        public static T TryParse<T>(string expression, Func<CronExpression, T> valueSelector, Func<ExceptionHandle, T> errorSelector) => TryParse(expression ?? string.Empty, null, valueSelector, errorSelector);

        public static T TryParse<T>(string expression, CronFormat? format, Func<CronExpression, T> valueSelector, Func<ExceptionHandle, T> errorSelector)
        {
            if (expression == null) throw new ArgumentNullException(nameof(expression));
            if (valueSelector == null) throw new ArgumentNullException(nameof(valueSelector));
            if (errorSelector == null) throw new ArgumentNullException(nameof(errorSelector));

            var tokens = expression.Split(StringSeparatorStock.Space, StringSplitOptions.RemoveEmptyEntries);

            var includingSeconds = format == CronFormat.IncludeSeconds;
            var expectedTokenCount = includingSeconds ? 6 : 5;
            if (tokens.Length < expectedTokenCount || tokens.Length > expectedTokenCount)
            {
                return errorSelector(() =>
                {
                    var components =
                        includingSeconds
                        ? "6 components of a schedule in the sequence of seconds, minutes, hours, days, months, and days of week"
                        : "5 components of a schedule in the sequence of minutes, hours, days, months, and days of week";
                    return new CronException($"'{expression}' is an invalid crontab expression. It must contain {components}.");
                });
            }

            var fields = new CronExpressionField[8];

            var offset = includingSeconds ? 0 : 1;
            for (var i = 0; i < tokens.Length; i++)
            {
                var kind = (CronFieldKind)i + offset;
                var field = CronExpressionField.TryParse(kind, tokens[i], static v => new { ExceptionHandle = (ExceptionHandle?)null, Value = (CronExpressionField?)v },
                                                                   static e => new { ExceptionHandle = (ExceptionHandle?)e, Value = (CronExpressionField?)null });

                if (field.ExceptionHandle != null)
                    return errorSelector(field.ExceptionHandle);
                fields[i + offset] = field.Value!; // non-null by mutual exclusivity!
            }

            return valueSelector(new CronExpression(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], fields[6], fields[7]));
        }

        CronExpression(CronExpressionField? seconds, CronExpressionField minutes, CronExpressionField hours, CronExpressionField days, CronExpressionField months, CronExpressionField weeks, CronExpressionField years, CronExpressionField workdays)
        {
            this.Seconds = seconds;
            this.Minutes = minutes;
            this.Hours = hours;
            this.Days = days;
            this.Months = months;
            this.Weeks = weeks;
            this.Years = years;
            this.Workdays = workdays;
        }

        /// <summary>
        /// Enumerates all the occurrences of this schedule starting with a base time and up to an end
        /// time limit. This method uses deferred execution such that the occurrences are only
        /// calculated as they are enumerated.
        /// </summary>
        /// <remarks>
        /// This method does not return the value of <paramref name="baseTime"/> itself if it falls on
        /// the schedule. For example, if <paramref name="baseTime" /> is midnight and the schedule was
        /// created from the expression <c>* * * * *</c> (meaning every minute) then the next occurrence
        /// of the schedule will be at one minute past midnight and not midnight itself. The method
        /// returns the <em>next</em> occurrence <em>after</em> <paramref name="baseTime"/>. Also,
        /// <param name="endTime" /> is exclusive.
        /// </remarks>

        public IEnumerable<DateTime> GetNextOccurrences(DateTime baseTime, DateTime endTime)
        {
            for (var occurrence = TryGetNextOccurrence(baseTime, endTime);
                 occurrence != null && occurrence < endTime;
                 occurrence = TryGetNextOccurrence(occurrence.Value, endTime))
            {
                yield return occurrence.Value;
            }
        }

        /// <summary>
        /// Gets the next occurrence of this schedule starting with a base time.
        /// </summary>

        public DateTime GetNextOccurrence(DateTime baseTime) =>
            GetNextOccurrence(baseTime, DateTime.MaxValue);

        /// <summary>
        /// Gets the next occurrence of this schedule starting with a base time and up to an end time
        /// limit.
        /// </summary>
        /// <remarks>
        /// This method does not return the value of <paramref name="baseTime"/> itself if it falls on
        /// the schedule. For example, if <paramref name="baseTime" /> is midnight and the schedule was
        /// created from the expression <c>* * * * *</c> (meaning every minute) then the next occurrence
        /// of the schedule will be at one minute past midnight and not midnight itself. The method
        /// returns the <em>next</em> occurrence <em>after</em> <paramref name="baseTime"/>. Also,
        /// <param name="endTime" /> is exclusive.
        /// </remarks>

        public DateTime GetNextOccurrence(DateTime baseTime, DateTime endTime) =>
            TryGetNextOccurrence(baseTime, endTime) ?? endTime;

        DateTime? TryGetNextOccurrence(DateTime baseTime, DateTime endTime)
        {
            const int nil = -1;

            var baseYear = baseTime.Year;
            var baseMonth = baseTime.Month;
            var baseDay = baseTime.Day;
            var baseHour = baseTime.Hour;
            var baseMinute = baseTime.Minute;
            var baseSecond = baseTime.Second;

            var endYear = endTime.Year;
            var endMonth = endTime.Month;
            var endDay = endTime.Day;

            var year = baseYear;
            var month = baseMonth;
            var day = baseDay;
            var hour = baseHour;
            var minute = baseMinute;
            var second = baseSecond + 1;

            //
            // Second
            //

            var seconds = this.Seconds ?? SecondZero;
            second = seconds.Next(second);

            if (second == nil)
            {
                second = seconds.GetFirst();
                minute++;
            }

            //
            // Minute
            //

            minute = this.Minutes.Next(minute);

            if (minute == nil)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
                hour++;
            }
            else if (minute > baseMinute)
            {
                second = seconds.GetFirst();
            }

            //
            // Hour
            //

            hour = this.Hours.Next(hour);

            if (hour == nil)
            {
                minute = this.Minutes.GetFirst();
                hour = this.Hours.GetFirst();
                day++;
            }
            else if (hour > baseHour)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
            }

            //
            // Day
            //

            day = this.Days.Next(day);

        RetryDayMonth:

            if (day == nil)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
                hour = this.Hours.GetFirst();
                day = this.Days.GetFirst();
                month++;
            }
            else if (day > baseDay)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
                hour = this.Hours.GetFirst();
            }

            //
            // Month
            //

            month = this.Months.Next(month);

            if (month == nil)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
                hour = this.Hours.GetFirst();
                day = this.Days.GetFirst();
                month = this.Months.GetFirst();
                year++;
            }
            else if (month > baseMonth)
            {
                second = seconds.GetFirst();
                minute = this.Minutes.GetFirst();
                hour = this.Hours.GetFirst();
                day = this.Days.GetFirst();
            }

            //
            // Stop processing when year is too large for the datetime or calendar object. Otherwise we
            // would get an exception.
            //

            if (year > Calendar.MaxSupportedDateTime.Year)
                return null;

            //
            // The day field in a cron expression spans the entire range of days in a month, which is
            // from 1 to 31. However, the number of days in a month tend to be variable depending on the
            // month (and the year in case of February). So a check is needed here to see if the date is
            // a border case. If the day happens to be beyond 28 (meaning that we're dealing with the
            // suspicious range of 29-31) and the date part has changed then we need to determine
            // whether the day still makes sense for the given year and month. If the day is beyond the
            // last possible value, then the day/month part for the schedule is re-evaluated. So an
            // expression like "0 0 15,31 * *" will yield the following sequence starting on midnight of
            // Jan 1, 2000:
            //
            //  Jan 15, Jan 31, Feb 15, Mar 15, Apr 15, Apr 31, ...
            //

            var dateChanged = day != baseDay || month != baseMonth || year != baseYear;

            if (day > 28 && dateChanged && day > Calendar.GetDaysInMonth(year, month))
            {
                if (year >= endYear && month >= endMonth && day >= endDay)
                    return endTime;

                day = nil;
                goto RetryDayMonth;
            }

            var nextTime = new DateTime(year, month, day, hour, minute, second, 0, baseTime.Kind);

            if (nextTime >= endTime)
                return endTime;

            //
            // Day of week
            //

            if (this.Weeks.Contains((int)nextTime.DayOfWeek))
            {
                return nextTime;
            }

            return TryGetNextOccurrence(new DateTime(year, month, day, 23, 59, 59, 0, baseTime.Kind), endTime);
        }

        /// <summary>
        /// Returns a string in crontab expression (expanded) that represents this schedule.
        /// </summary>

        public override string ToString()
        {
            using var writer = new StringWriter(CultureInfo.InvariantCulture);

            if (this.Seconds != null)
            {
                this.Seconds.Format(writer, true);
                writer.Write(' ');
            }
            this.Minutes.Format(writer, true); writer.Write(' ');
            this.Hours.Format(writer, true); writer.Write(' ');
            this.Days.Format(writer, true); writer.Write(' ');
            this.Months.Format(writer, true); writer.Write(' ');
            this.Weeks.Format(writer, true);

            return writer.ToString();
        }

        static Calendar Calendar => CultureInfo.InvariantCulture.Calendar;
    }
}
